diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index aedacbaf21c3f..3691f82a56259 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -13,7 +13,8 @@ "documentDiff", "documentSyntaxHighlighting", "textEditorDiffInformation", - "customEditorPriority" + "customEditorPriority", + "webviewNoServiceWorker" ], "engines": { "vscode": "^1.70.0" diff --git a/src/vs/code/electron-browser/workbench/workbench-dev.html b/src/vs/code/electron-browser/workbench/workbench-dev.html index 9e2d94074bc3d..cd21642d6c78a 100644 --- a/src/vs/code/electron-browser/workbench/workbench-dev.html +++ b/src/vs/code/electron-browser/workbench/workbench-dev.html @@ -70,6 +70,7 @@ notebookChatEditController richScreenReaderContent chatDebugTokenizer + singleIframeWebview ; "/> diff --git a/src/vs/code/electron-browser/workbench/workbench.html b/src/vs/code/electron-browser/workbench/workbench.html index 47c5748b8a339..7d4706ec2ffcb 100644 --- a/src/vs/code/electron-browser/workbench/workbench.html +++ b/src/vs/code/electron-browser/workbench/workbench.html @@ -66,6 +66,7 @@ notebookChatEditController richScreenReaderContent chatDebugTokenizer + singleIframeWebview ; "/> diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 66997cbf0dfef..37da0490a5bed 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -95,6 +95,7 @@ import { NativeURLService } from '../../platform/url/common/urlService.js'; import { ElectronURLListener } from '../../platform/url/electron-main/electronUrlListener.js'; import { IWebviewManagerService } from '../../platform/webview/common/webviewManagerService.js'; import { WebviewMainService } from '../../platform/webview/electron-main/webviewMainService.js'; +import { WebviewProtocolProvider } from '../../platform/webview/electron-main/webviewProtocolProvider.js'; import { isFolderToOpen, isWorkspaceToOpen, IWindowOpenable } from '../../platform/window/common/window.js'; import { getAllWindowsExcludingOffscreen, IWindowsMainService, OpenContext } from '../../platform/windows/electron-main/windows.js'; import { ICodeWindow } from '../../platform/window/electron-main/window.js'; @@ -387,6 +388,22 @@ export class CodeApplication extends Disposable { }; const isAllowedWebviewRequest = (uri: URI, details: Electron.OnBeforeRequestListenerDetails): boolean => { + const directDocument = WebviewProtocolProvider.getWebviewDocument(uri); + if (directDocument) { + const frame = details.frame; + const owner = this.windowsMainService?.getWindowById(directDocument.windowId)?.win; + if (!frame || !owner) { + return false; + } + if (frame.frameTreeNodeId !== directDocument.frameTreeNodeId) { + return false; + } + const route = `${Schemas.vscodeWebview}://${directDocument.extensionId.toLowerCase()}/${encodeURIComponent(directDocument.webviewId)}/`; + const isInitialNavigation = frame.url === '' + || frame.url === 'about:blank' + || frame.url.startsWith(`${Schemas.vscodeFileResource}://`); + return isInitialNavigation || frame.url.startsWith(route); + } if (uri.path !== '/index.html') { return true; // Only restrict top level page of webviews: index.html } @@ -410,6 +427,13 @@ export class CodeApplication extends Disposable { session.defaultSession.webRequest.onBeforeRequest((details, callback) => { const uri = URI.parse(details.url); + if ((uri.scheme === Schemas.http || uri.scheme === Schemas.https) && details.frame) { + const portMapping = WebviewProtocolProvider.getWebviewPortMapping(details.frame.url, details.url); + if (portMapping) { + void portMapping.then(redirectURL => callback(redirectURL ? { redirectURL } : { cancel: false })); + return; + } + } if (uri.scheme === Schemas.vscodeWebview) { if (!isAllowedWebviewRequest(uri, details)) { this.logService.error('Blocked vscode-webview request', details.url); diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index d5630b57899d3..089beab85ae80 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -531,6 +531,9 @@ const _allApiProposals = { valueSelectionInQuickPick: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.valueSelectionInQuickPick.d.ts', }, + webviewNoServiceWorker: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.webviewNoServiceWorker.d.ts', + }, workspaceTrust: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.workspaceTrust.d.ts', } diff --git a/src/vs/platform/webview/common/resourceLoading.ts b/src/vs/platform/webview/common/resourceLoading.ts new file mode 100644 index 0000000000000..ea6445688ac74 --- /dev/null +++ b/src/vs/platform/webview/common/resourceLoading.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isUNC } from '../../../base/common/extpath.js'; +import { Schemas } from '../../../base/common/network.js'; +import { URI } from '../../../base/common/uri.js'; +import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; + +export function isWebviewResourceAllowed(resource: URI, roots: readonly URI[], uriIdentityService: IUriIdentityService): boolean { + const resourceWithoutQuery = resource.with({ query: '' }); + for (const root of roots) { + if (uriIdentityService.extUri.isEqual(root, resourceWithoutQuery, true)) { + continue; + } + + // Compare UNC paths case-insensitively. + if (root.scheme === Schemas.file && isUNC(root.fsPath)) { + if (resourceWithoutQuery.scheme === Schemas.file && isUNC(resourceWithoutQuery.fsPath) + && uriIdentityService.extUri.isEqualOrParent( + resourceWithoutQuery.with({ path: resourceWithoutQuery.path.toLowerCase(), authority: resourceWithoutQuery.authority.toLowerCase() }), + root.with({ path: root.path.toLowerCase(), authority: root.authority.toLowerCase() }), + true, + )) { + return true; + } + } else if (uriIdentityService.extUri.isEqualOrParent(resourceWithoutQuery, root, true)) { + return true; + } + } + return false; +} diff --git a/src/vs/platform/webview/common/webviewManagerService.ts b/src/vs/platform/webview/common/webviewManagerService.ts index ff195b9d2ad70..5cdcb22ac39b1 100644 --- a/src/vs/platform/webview/common/webviewManagerService.ts +++ b/src/vs/platform/webview/common/webviewManagerService.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; +import { UriComponents } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IWebviewManagerService = createDecorator('webviewManagerService'); @@ -33,6 +35,16 @@ export interface IWebviewManagerService { _serviceBrand: unknown; readonly onFoundInFrame: Event; + readonly onDidRequestWebviewResource: Event; + readonly onDidCancelWebviewResource: Event; + readonly onDidRequestWebviewPortMapping: Event; + + registerWebviewDocument(document: WebviewDocumentRegistration): Promise; + unregisterWebviewDocument(extensionId: string, webviewId: string): Promise; + startWebviewResourceResponse(response: WebviewResourceResponse): Promise; + streamWebviewResourceResponse(requestId: number, data: VSBuffer): Promise; + endWebviewResourceResponse(requestId: number, error?: boolean): Promise; + resolveWebviewPortMapping(requestId: number, redirect: string | undefined): Promise; setIgnoreMenuShortcuts(id: WebviewWebContentsId | WebviewWindowId, enabled: boolean): Promise; @@ -40,3 +52,40 @@ export interface IWebviewManagerService { stopFindInFrame(windowId: WebviewWindowId, frameName: string, options: { keepSelection?: boolean }): Promise; } + +export interface WebviewDocumentRegistration { + readonly extensionId: string; + readonly webviewId: string; + readonly windowId: number; + readonly frameName: string; + readonly html: string; + readonly csp: string; + readonly roots: readonly UriComponents[]; +} + +export interface WebviewResourceRequest { + readonly requestId: number; + readonly extensionId: string; + readonly webviewId: string; + readonly method: 'GET' | 'HEAD'; + readonly uri: UriComponents; + readonly ifNoneMatch: string | undefined; + readonly range: { readonly start: number; readonly end?: number } | undefined; +} + +export interface WebviewResourceResponse { + readonly requestId: number; + readonly status: number; + readonly mime: string | undefined; + readonly etag: string | undefined; + readonly mtime: number | undefined; + readonly size: number | undefined; + readonly range: string | undefined; +} + +export interface WebviewPortMappingRequest { + readonly requestId: number; + readonly extensionId: string; + readonly webviewId: string; + readonly origin: string; +} diff --git a/src/vs/platform/webview/electron-main/webviewMainService.ts b/src/vs/platform/webview/electron-main/webviewMainService.ts index 9c583a0d95a6a..679d79d1048ad 100644 --- a/src/vs/platform/webview/electron-main/webviewMainService.ts +++ b/src/vs/platform/webview/electron-main/webviewMainService.ts @@ -6,10 +6,11 @@ import { WebContents, webContents, WebFrameMain } from 'electron'; import { Emitter } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; -import { FindInFrameOptions, FoundInFrameResult, IWebviewManagerService, WebviewWebContentsId, WebviewWindowId } from '../common/webviewManagerService.js'; +import { FindInFrameOptions, FoundInFrameResult, IWebviewManagerService, WebviewDocumentRegistration, WebviewPortMappingRequest, WebviewResourceRequest, WebviewResourceResponse, WebviewWebContentsId, WebviewWindowId } from '../common/webviewManagerService.js'; import { WebviewProtocolProvider } from './webviewProtocolProvider.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { IFileService } from '../../files/common/files.js'; +import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; export class WebviewMainService extends Disposable implements IWebviewManagerService { @@ -17,13 +18,63 @@ export class WebviewMainService extends Disposable implements IWebviewManagerSer private readonly _onFoundInFrame = this._register(new Emitter()); public readonly onFoundInFrame = this._onFoundInFrame.event; + private readonly _onDidRequestWebviewResource = this._register(new Emitter()); + public readonly onDidRequestWebviewResource = this._onDidRequestWebviewResource.event; + private readonly _onDidCancelWebviewResource = this._register(new Emitter()); + public readonly onDidCancelWebviewResource = this._onDidCancelWebviewResource.event; + private readonly _onDidRequestWebviewPortMapping = this._register(new Emitter()); + public readonly onDidRequestWebviewPortMapping = this._onDidRequestWebviewPortMapping.event; + private readonly protocolProvider: WebviewProtocolProvider; constructor( @IFileService fileService: IFileService, + @IUriIdentityService uriIdentityService: IUriIdentityService, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, ) { super(); - this._register(new WebviewProtocolProvider(fileService)); + this.protocolProvider = this._register(new WebviewProtocolProvider( + request => this._onDidRequestWebviewResource.fire(request), + requestId => this._onDidCancelWebviewResource.fire(requestId), + request => this._onDidRequestWebviewPortMapping.fire(request), + uriIdentityService, + fileService, + )); + this._register(this.windowsMainService.onDidDestroyWindow(window => this.protocolProvider.unregisterWebviewWindow(window.id))); + } + + public async registerWebviewDocument(document: WebviewDocumentRegistration): Promise { + const window = this.windowsMainService.getWindowById(document.windowId); + const mainFrame = window?.win?.webContents.mainFrame; + const frame = mainFrame?.framesInSubtree.find(frame => { + return frame.parent === mainFrame && frame.name === document.frameName; + }); + if (!frame) { + throw new Error(`Unknown direct webview frame: ${document.frameName}`); + } + this.protocolProvider.registerWebviewDocument({ + ...document, + frameTreeNodeId: frame.frameTreeNodeId, + }); + } + + public async unregisterWebviewDocument(extensionId: string, webviewId: string): Promise { + this.protocolProvider.unregisterWebviewDocument(extensionId, webviewId); + } + + public async startWebviewResourceResponse(response: WebviewResourceResponse): Promise { + this.protocolProvider.startResourceResponse(response); + } + + public async streamWebviewResourceResponse(requestId: number, data: import('../../../base/common/buffer.js').VSBuffer): Promise { + this.protocolProvider.streamResourceResponse(requestId, data); + } + + public async endWebviewResourceResponse(requestId: number, error?: boolean): Promise { + this.protocolProvider.endResourceResponse(requestId, error); + } + + public async resolveWebviewPortMapping(requestId: number, redirect: string | undefined): Promise { + this.protocolProvider.resolvePortMapping(requestId, redirect); } public async setIgnoreMenuShortcuts(id: WebviewWebContentsId | WebviewWindowId, enabled: boolean): Promise { diff --git a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts index 9f8a59c68aed4..2f3bf72c60c83 100644 --- a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts +++ b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts @@ -3,36 +3,202 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { protocol } from 'electron'; +import { net, protocol } from 'electron'; +import { VSBuffer } from '../../../base/common/buffer.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { AppResourcePath, COI, FileAccess, Schemas } from '../../../base/common/network.js'; import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; +import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; +import { getWebviewContentMimeType } from '../common/mimeTypes.js'; +import { isWebviewResourceAllowed } from '../common/resourceLoading.js'; +import { WebviewDocumentRegistration, WebviewPortMappingRequest, WebviewResourceRequest, WebviewResourceResponse } from '../common/webviewManagerService.js'; +export interface RegisteredWebviewDocument extends WebviewDocumentRegistration { + readonly frameTreeNodeId: number; +} export class WebviewProtocolProvider implements IDisposable { + private static instance: WebviewProtocolProvider | undefined; + private static readonly documents = new Map(); private static validWebviewFilePaths = new Map([ ['/index.html', { mime: 'text/html' }], ['/fake.html', { mime: 'text/html' }], ['/service-worker.js', { mime: 'application/javascript' }], + ['/defaultStyles.js', { mime: 'application/javascript' }], ]); + private readonly pendingResources = new Map void; + readonly method: string; + readonly extensionId: string; + readonly webviewId: string; + controller: ReadableStreamDefaultController | undefined; + }>(); + private nextRequestId = 1; + private readonly pendingPortMappings = new Map void>(); constructor( + private readonly requestResource: (request: WebviewResourceRequest) => void, + private readonly cancelResource: (requestId: number) => void, + private readonly requestPortMapping: (request: WebviewPortMappingRequest) => void, + private readonly uriIdentityService: IUriIdentityService, @IFileService private readonly _fileService: IFileService ) { // Register the protocol for loading webview html const webviewHandler = this.handleWebviewRequest.bind(this); protocol.handle(Schemas.vscodeWebview, webviewHandler); + WebviewProtocolProvider.instance = this; } dispose(): void { protocol.unhandle(Schemas.vscodeWebview); + for (const [requestId, pending] of this.pendingResources) { + pending.resolve(new Response(null, { status: 499 })); + this.cancelResource(requestId); + } + this.pendingResources.clear(); + WebviewProtocolProvider.documents.clear(); + for (const resolve of this.pendingPortMappings.values()) { resolve(undefined); } + this.pendingPortMappings.clear(); + if (WebviewProtocolProvider.instance === this) { WebviewProtocolProvider.instance = undefined; } + } + + public registerWebviewDocument(document: RegisteredWebviewDocument): void { + if (!/^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/.test(document.extensionId.toLowerCase()) || !document.webviewId || document.webviewId.includes('/')) { + throw new Error('Invalid direct webview route'); + } + const key = this.documentKey(document.extensionId, document.webviewId); + WebviewProtocolProvider.documents.set(key, document); + } + + public unregisterWebviewDocument(extensionId: string, webviewId: string): void { + const key = this.documentKey(extensionId, webviewId); + WebviewProtocolProvider.documents.delete(key); + for (const [requestId, pending] of this.pendingResources) { + if (pending.extensionId.toLowerCase() === extensionId.toLowerCase() && pending.webviewId === webviewId) { + this.pendingResources.delete(requestId); + pending.controller?.error(new Error('Webview disposed')); + pending.resolve(new Response(null, { status: 499 })); + this.cancelResource(requestId); + } + } + } + + public unregisterWebviewWindow(windowId: number): void { + for (const document of [...WebviewProtocolProvider.documents.values()]) { + if (document.windowId === windowId) { + this.unregisterWebviewDocument(document.extensionId, document.webviewId); + } + } + } + + public startResourceResponse(response: WebviewResourceResponse): void { + const pending = this.pendingResources.get(response.requestId); + if (!pending) { + return; + } + const headers: Record = { + 'Access-Control-Allow-Origin': '*', + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'X-Content-Type-Options': 'nosniff', + }; + if (response.mime) { headers['Content-Type'] = response.mime; } + if (response.etag) { headers['ETag'] = response.etag; } + if (response.mtime !== undefined) { headers['Last-Modified'] = new Date(response.mtime).toUTCString(); } + if (response.range) { headers['Content-Range'] = response.range; headers['Accept-Ranges'] = 'bytes'; } + if (response.size !== undefined) { headers['Content-Length'] = String(response.range ? this.rangeLength(response.range) : response.size); } + + if (pending.method === 'HEAD' || response.status === 304 || response.status >= 400) { + this.pendingResources.delete(response.requestId); + pending.resolve(new Response(null, { status: response.status, headers })); + return; + } + const body = new ReadableStream({ + start: controller => pending.controller = controller, + cancel: () => { + this.pendingResources.delete(response.requestId); + this.cancelResource(response.requestId); + }, + }); + pending.resolve(new Response(body, { status: response.status, headers })); + } + + public streamResourceResponse(requestId: number, data: VSBuffer): void { + this.pendingResources.get(requestId)?.controller?.enqueue(data.buffer); + } + + public endResourceResponse(requestId: number, error?: boolean): void { + const pending = this.pendingResources.get(requestId); + if (!pending) { + return; + } + this.pendingResources.delete(requestId); + if (error) { + pending.controller?.error(new Error('Webview resource read failed')); + } else { + pending.controller?.close(); + } + } + + public static getWebviewDocument(url: URI): RegisteredWebviewDocument | undefined { + const match = /^\/([^/]+)\/(?:index\.html|_vscode\/resource\/)/.exec(url.path); + if (!match) { + return undefined; + } + return this.documents.get(`${url.authority.toLowerCase()}\0${decodeURIComponent(match[1])}`); + } + + public static getWebviewPortMapping(frameUrl: string, targetUrl: string): Promise | undefined { + const instance = this.instance; + if (!instance) { return undefined; } + let frameUri: URI; + let target: URL; + try { + frameUri = URI.parse(frameUrl); + target = new URL(targetUrl); + } catch { + return undefined; + } + const document = this.getWebviewDocument(frameUri); + if (!document || !['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)) { + return undefined; + } + const route = `${Schemas.vscodeWebview}://${document.extensionId.toLowerCase()}/${encodeURIComponent(document.webviewId)}/`; + if (!frameUrl.startsWith(route)) { + return undefined; + } + return instance.requestPortMappingForDocument(document, target.origin); + } + + private requestPortMappingForDocument(document: WebviewDocumentRegistration, origin: string): Promise { + const requestId = this.nextRequestId++; + return new Promise(resolve => { + this.pendingPortMappings.set(requestId, resolve); + this.requestPortMapping({ requestId, extensionId: document.extensionId, webviewId: document.webviewId, origin }); + setTimeout(() => this.resolvePortMapping(requestId, undefined), 10_000); + }); + } + + public resolvePortMapping(requestId: number, redirect: string | undefined): void { + const resolve = this.pendingPortMappings.get(requestId); + if (resolve) { + this.pendingPortMappings.delete(requestId); + resolve(redirect); + } + } + + private documentKey(extensionId: string, webviewId: string): string { + return `${extensionId.toLowerCase()}\0${webviewId}`; } private async handleWebviewRequest(request: GlobalRequest): Promise { try { const uri = URI.parse(request.url); + const directResponse = await this.handleDirectWebviewRequest(request, uri); + if (directResponse) { + return directResponse; + } const entry = WebviewProtocolProvider.validWebviewFilePaths.get(uri.path); if (entry) { const relativeResourcePath: AppResourcePath = `vs/workbench/contrib/webview/browser/pre${uri.path}`; @@ -54,4 +220,136 @@ export class WebviewProtocolProvider implements IDisposable { } return new Response(null, { status: 500 }); } + + private async handleDirectWebviewRequest(request: GlobalRequest, uri: URI): Promise { + const match = /^\/([^/]+)\/(index\.html|_vscode\/resource\/(.+))$/.exec(uri.path); + if (!match) { + return undefined; + } + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response(null, { status: 405 }); + } + + const webviewId = decodeURIComponent(match[1]); + const document = WebviewProtocolProvider.documents.get(this.documentKey(uri.authority, webviewId)); + if (!document) { + return new Response(null, { status: 404 }); + } + + if (match[2] === 'index.html') { + return new Response(request.method === 'HEAD' ? null : document.html, { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Security-Policy': document.csp, + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + 'Cross-Origin-Resource-Policy': 'cross-origin', + } + }); + } + + const resource = this.decodeResourceUri(match[3], uri.query); + if (!resource) { + return new Response(null, { status: 403 }); + } + const localResponse = await this.tryLoadLocalFileResource(request, resource, document); + if (localResponse) { + return localResponse; + } + if (this.pendingResources.size >= 128) { + return new Response(null, { status: 429 }); + } + const requestId = this.nextRequestId++; + const range = this.parseRange(request.headers.get('range')); + return new Promise(resolve => { + this.pendingResources.set(requestId, { + resolve, + method: request.method, + extensionId: document.extensionId, + webviewId: document.webviewId, + controller: undefined, + }); + request.signal.addEventListener('abort', () => { + if (this.pendingResources.delete(requestId)) { + this.cancelResource(requestId); + resolve(new Response(null, { status: 499 })); + } + }, { once: true }); + this.requestResource({ + requestId, + extensionId: document.extensionId, + webviewId: document.webviewId, + method: request.method as 'GET' | 'HEAD', + uri: resource, + ifNoneMatch: request.headers.get('if-none-match') ?? undefined, + range, + }); + }); + } + + private async tryLoadLocalFileResource(request: GlobalRequest, resource: URI, document: WebviewDocumentRegistration): Promise { + if (resource.scheme !== Schemas.file + || request.headers.has('range') + || request.headers.has('if-none-match') + || !isWebviewResourceAllowed(resource, document.roots.map(root => URI.revive(root)), this.uriIdentityService)) { + return undefined; + } + + try { + const response = await net.fetch(resource.toString(true), { + method: request.method, + signal: request.signal, + bypassCustomProtocolHandlers: true, + }); + const headers = new Headers(response.headers); + headers.set('Content-Type', getWebviewContentMimeType(resource)); + headers.set('Access-Control-Allow-Origin', '*'); + headers.set('Cross-Origin-Resource-Policy', 'cross-origin'); + headers.set('X-Content-Type-Options', 'nosniff'); + return new Response(request.method === 'HEAD' ? null : response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } catch { + return undefined; + } + } + + private parseRange(value: string | null): { start: number; end?: number } | undefined { + if (!value) { return undefined; } + const match = /^bytes=(\d+)-(\d*)$/.exec(value); + if (!match) { return undefined; } + return { start: Number(match[1]), end: match[2] ? Number(match[2]) : undefined }; + } + + private rangeLength(value: string): number { + const match = /^bytes (\d+)-(\d+)\//.exec(value); + return match ? Number(match[2]) - Number(match[1]) + 1 : 0; + } + + private decodeResourceUri(value: string, query: string): URI | undefined { + try { + const slash = value.indexOf('/'); + const encodedOrigin = slash < 0 ? value : value.slice(0, slash); + const plus = encodedOrigin.indexOf('+'); + if (plus <= 0) { + return undefined; + } + return URI.from({ + scheme: encodedOrigin.slice(0, plus), + authority: this.decodeAuthority(encodedOrigin.slice(plus + 1)), + path: slash < 0 ? '/' : value.slice(slash), + query, + }); + } catch { + return undefined; + } + } + + private decodeAuthority(authority: string): string { + return authority.replace(/-([0-9a-f]{4})/g, (_, code) => String.fromCharCode(parseInt(code, 16))); + } + } diff --git a/src/vs/workbench/api/browser/mainThreadWebviews.ts b/src/vs/workbench/api/browser/mainThreadWebviews.ts index 0d5997132362a..ae6c1c5451435 100644 --- a/src/vs/workbench/api/browser/mainThreadWebviews.ts +++ b/src/vs/workbench/api/browser/mainThreadWebviews.ts @@ -48,6 +48,7 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma throw new Error('Webview already registered'); } + webview.resourceId = handle; this._webviews.set(handle, webview); this.hookupWebviewEventDelegate(handle, webview, options); } @@ -145,6 +146,7 @@ export function reviveWebviewExtension(extensionData: extHostProtocol.WebviewExt return { id: extensionData.id, location: URI.revive(extensionData.location), + useSingleIframe: extensionData.useSingleIframe, }; } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index bf42962ec0b01..0aedd86a0cc2a 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -236,7 +236,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostLabelService = rpcProtocol.set(ExtHostContext.ExtHostLabelService, new ExtHostLabelService(rpcProtocol)); const extHostTheming = rpcProtocol.set(ExtHostContext.ExtHostTheming, new ExtHostTheming(rpcProtocol)); const extHostTimeline = rpcProtocol.set(ExtHostContext.ExtHostTimeline, new ExtHostTimeline(rpcProtocol, extHostCommands)); - const extHostWebviews = rpcProtocol.set(ExtHostContext.ExtHostWebviews, new ExtHostWebviews(rpcProtocol, initData.remote, extHostWorkspace, extHostLogService, extHostApiDeprecation)); + const extHostWebviews = rpcProtocol.set(ExtHostContext.ExtHostWebviews, new ExtHostWebviews(rpcProtocol, initData.remote, initData.uiKind, extHostWorkspace, extHostLogService, extHostApiDeprecation)); const extHostWebviewPanels = rpcProtocol.set(ExtHostContext.ExtHostWebviewPanels, new ExtHostWebviewPanels(rpcProtocol, extHostWebviews, extHostWorkspace)); const extHostCustomEditors = rpcProtocol.set(ExtHostContext.ExtHostCustomEditors, new ExtHostCustomEditors(rpcProtocol, extHostDocuments, extensionStoragePaths, extHostWebviews, extHostWebviewPanels)); const extHostWebviewViews = rpcProtocol.set(ExtHostContext.ExtHostWebviewViews, new ExtHostWebviewViews(rpcProtocol, extHostWebviews)); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index a4b2b6372c252..1a100d814787a 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1018,6 +1018,7 @@ export interface WebviewPanelShowOptions { export interface WebviewExtensionDescription { readonly id: ExtensionIdentifier; readonly location: UriComponents; + readonly useSingleIframe?: boolean; } export enum WebviewEditorCapabilities { diff --git a/src/vs/workbench/api/common/extHostWebview.ts b/src/vs/workbench/api/common/extHostWebview.ts index 574088e5aba80..5734106fc2115 100644 --- a/src/vs/workbench/api/common/extHostWebview.ts +++ b/src/vs/workbench/api/common/extHostWebview.ts @@ -17,6 +17,8 @@ import { deserializeWebviewMessage, serializeWebviewMessage } from './extHostWeb import { IExtHostWorkspace } from './extHostWorkspace.js'; import { WebviewRemoteInfo, asWebviewUri, webviewGenericCspSource } from '../../contrib/webview/common/webview.js'; import { SerializableObjectWithBuffers } from '../../services/extensions/common/proxyIdentifier.js'; +import { UIKind } from '../../services/extensions/common/extensionHostProtocol.js'; +import { isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; import type * as vscode from 'vscode'; import * as extHostProtocol from './extHost.protocol.js'; @@ -29,6 +31,7 @@ export class ExtHostWebview implements vscode.Webview { readonly #remoteInfo: WebviewRemoteInfo; readonly #workspace: IExtHostWorkspace | undefined; readonly #extension: IExtensionDescription; + readonly #useSingleIframe: boolean; #html: string = ''; #options: vscode.WebviewOptions; @@ -45,6 +48,7 @@ export class ExtHostWebview implements vscode.Webview { remoteInfo: WebviewRemoteInfo, workspace: IExtHostWorkspace | undefined, extension: IExtensionDescription, + useSingleIframe: boolean, deprecationService: IExtHostApiDeprecationService, ) { this.#handle = handle; @@ -53,6 +57,7 @@ export class ExtHostWebview implements vscode.Webview { this.#remoteInfo = remoteInfo; this.#workspace = workspace; this.#extension = extension; + this.#useSingleIframe = useSingleIframe; this.#serializeBuffersForPostMessage = shouldSerializeBuffersForPostMessage(extension); this.#shouldRewriteOldResourceUris = shouldTryRewritingOldResourceUris(extension); this.#deprecationService = deprecationService; @@ -75,10 +80,16 @@ export class ExtHostWebview implements vscode.Webview { public asWebviewUri(resource: vscode.Uri): vscode.Uri { this.#hasCalledAsWebviewUri = true; - return asWebviewUri(resource, this.#remoteInfo); + return asWebviewUri(resource, this.#remoteInfo, this.#useSingleIframe ? { + extensionId: this.#extension.identifier.value, + webviewId: this.#handle, + } : undefined); } public get cspSource(): string { + if (this.#useSingleIframe) { + return `${Schemas.vscodeWebview}://${this.#extension.identifier.value.toLowerCase()}`; + } const extensionLocation = this.#extension.extensionLocation; if (extensionLocation.scheme === Schemas.https || extensionLocation.scheme === Schemas.http) { // The extension is being served up from a CDN. @@ -198,6 +209,7 @@ export class ExtHostWebviews extends Disposable implements extHostProtocol.ExtHo constructor( mainContext: extHostProtocol.IMainContext, private readonly remoteInfo: WebviewRemoteInfo, + private readonly uiKind: UIKind, private readonly workspace: IExtHostWorkspace | undefined, private readonly _logService: ILogService, private readonly _deprecationService: IExtHostApiDeprecationService, @@ -235,7 +247,8 @@ export class ExtHostWebviews extends Disposable implements extHostProtocol.ExtHo } public createNewWebview(handle: string, options: extHostProtocol.IWebviewContentOptions, extension: IExtensionDescription): ExtHostWebview { - const webview = new ExtHostWebview(handle, this._webviewProxy, reviveOptions(options), this.remoteInfo, this.workspace, extension, this._deprecationService); + const useSingleIframe = this.uiKind === UIKind.Desktop && isProposedApiEnabled(extension, 'webviewNoServiceWorker'); + const webview = new ExtHostWebview(handle, this._webviewProxy, reviveOptions(options), this.remoteInfo, this.workspace, extension, useSingleIframe, this._deprecationService); this._webviews.set(handle, webview); const sub = webview._onDidDispose(() => { @@ -276,7 +289,11 @@ export class ExtHostWebviews extends Disposable implements extHostProtocol.ExtHo } export function toExtensionData(extension: IExtensionDescription): extHostProtocol.WebviewExtensionDescription { - return { id: extension.identifier, location: extension.extensionLocation }; + return { + id: extension.identifier, + location: extension.extensionLocation, + useSingleIframe: isProposedApiEnabled(extension, 'webviewNoServiceWorker'), + }; } export function serializeWebviewOptions( diff --git a/src/vs/workbench/api/test/browser/extHostWebview.test.ts b/src/vs/workbench/api/test/browser/extHostWebview.test.ts index 3865db39383c7..9719d24dcbc6a 100644 --- a/src/vs/workbench/api/test/browser/extHostWebview.test.ts +++ b/src/vs/workbench/api/test/browser/extHostWebview.test.ts @@ -16,10 +16,11 @@ import { NullApiDeprecationService } from '../../common/extHostApiDeprecationSer import { IExtHostRpcService } from '../../common/extHostRpcService.js'; import { IWebviewContentOptions } from '../../common/extHost.protocol.js'; import { ExtHostWebviews } from '../../common/extHostWebview.js'; +import { UIKind } from '../../../services/extensions/common/extensionHostProtocol.js'; import { ExtHostWebviewPanels } from '../../common/extHostWebviewPanels.js'; import { IExtHostWorkspace } from '../../common/extHostWorkspace.js'; import { SingleProxyRPCProtocol } from '../common/testRPCProtocol.js'; -import { decodeAuthority, webviewResourceBaseHost } from '../../../contrib/webview/common/webview.js'; +import { decodeAuthority, webviewGenericCspSource, webviewResourceBaseHost } from '../../../contrib/webview/common/webview.js'; import { EditorGroupColumn } from '../../../services/editor/common/editorGroupColumn.js'; import { IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js'; import type * as vscode from 'vscode'; @@ -45,7 +46,7 @@ suite('ExtHostWebview', () => { const extHostWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: remoteAuthority, isRemote: !!remoteAuthority, - }, undefined, new NullLogService(), NullApiDeprecationService)); + }, UIKind.Desktop, undefined, new NullLogService(), NullApiDeprecationService)); const extHostWebviewPanels = disposables.add(new ExtHostWebviewPanels(rpcProtocol!, extHostWebviews, undefined)); @@ -61,7 +62,7 @@ suite('ExtHostWebview', () => { test('Cannot register multiple serializers for the same view type', async () => { const viewType = 'view.type'; - const extHostWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: undefined, isRemote: false }, undefined, new NullLogService(), NullApiDeprecationService)); + const extHostWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: undefined, isRemote: false }, UIKind.Desktop, undefined, new NullLogService(), NullApiDeprecationService)); const extHostWebviewPanels = disposables.add(new ExtHostWebviewPanels(rpcProtocol!, extHostWebviews, undefined)); @@ -198,6 +199,38 @@ suite('ExtHostWebview', () => { ); }); + test('webviewNoServiceWorker is gated to proposed API on desktop', () => { + const extension = { + identifier: { value: 'Publisher.Extension' }, + extensionLocation: URI.file('/extension'), + enabledApiProposals: ['webviewNoServiceWorker'], + } as unknown as IExtensionDescription; + const desktopWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: undefined, isRemote: false }, UIKind.Desktop, undefined, new NullLogService(), NullApiDeprecationService)); + const desktopWebview = desktopWebviews.createNewWebview('readable-instance', {}, extension); + assert.strictEqual(desktopWebview.cspSource, 'vscode-webview://publisher.extension'); + assert.strictEqual( + desktopWebview.asWebviewUri(URI.file('/extension/media/icon.svg')).toString(), + 'vscode-webview://publisher.extension/readable-instance/_vscode/resource/file%2B/extension/media/icon.svg', + ); + + const webWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: undefined, isRemote: false }, UIKind.Web, undefined, new NullLogService(), NullApiDeprecationService)); + const webWebview = webWebviews.createNewWebview('readable-instance', {}, extension); + assert.strictEqual(webWebview.cspSource, webviewGenericCspSource); + assert.strictEqual(webWebview.asWebviewUri(URI.file('/extension/media/icon.svg')).scheme, Schemas.https); + }); + + test('webviewNoServiceWorker requires the proposal', () => { + const extension = { + identifier: { value: 'publisher.extension' }, + extensionLocation: URI.file('/extension'), + enabledApiProposals: [], + } as unknown as IExtensionDescription; + const extHostWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: undefined, isRemote: false }, UIKind.Desktop, undefined, new NullLogService(), NullApiDeprecationService)); + const webview = extHostWebviews.createNewWebview('instance', {}, extension); + assert.strictEqual(webview.cspSource, webviewGenericCspSource); + assert.strictEqual(webview.asWebviewUri(URI.file('/extension/media/icon.svg')).scheme, Schemas.https); + }); + suite('ensureDefaultContentOptions', () => { function createExtHostWebviewsWithCapture(workspaceFolders: URI[] | undefined) { const setOptionsCalls: { handle: string; options: IWebviewContentOptions }[] = []; @@ -221,6 +254,7 @@ suite('ExtHostWebview', () => { const extHostWebviews = disposables.add(new ExtHostWebviews( captureRpc, { authority: undefined, isRemote: false }, + UIKind.Desktop, workspace, new NullLogService(), NullApiDeprecationService)); diff --git a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts index a47e2a26d4b79..a90f64db63959 100644 --- a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts +++ b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts @@ -36,6 +36,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { private _title: string | undefined; private _initialScrollProgress: number = 0; private _state: string | undefined = undefined; + private _resourceId: string | undefined; private _extension: WebviewExtensionDescription | undefined; private _contentOptions: WebviewContentOptions; @@ -217,6 +218,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { extension: this.extension, }); this._webview.value = webview; + webview.resourceId = this._resourceId; webview.state = this._state; if (this._scopedContextKeyService.value) { @@ -404,4 +406,10 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { setContextKeyService(contextKeyService: IContextKeyService) { this._webview.value?.setContextKeyService(contextKeyService); } + + public get resourceId(): string | undefined { return this._resourceId; } + public set resourceId(value: string | undefined) { + this._resourceId = value; + this._withWebview(webview => webview.resourceId = value); + } } diff --git a/src/vs/workbench/contrib/webview/browser/pre/defaultStyles.ts b/src/vs/workbench/contrib/webview/browser/pre/defaultStyles.ts new file mode 100644 index 0000000000000..aa2a2cb913eea --- /dev/null +++ b/src/vs/workbench/contrib/webview/browser/pre/defaultStyles.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const defaultWebviewStyles = `@layer vscode-default { + html { + scrollbar-color: var(--vscode-scrollbarSlider-background) var(--vscode-editor-background); + } + + body { + overscroll-behavior-x: none; + background-color: transparent; + 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, video { + max-width: 100%; + max-height: 100%; + } + + a, a code { + color: var(--vscode-textLink-foreground); + } + + p > a { + text-decoration: var(--text-link-decoration); + } + + 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 { + font-family: var(--monaco-monospace-font); + color: var(--vscode-textPreformat-foreground); + background-color: var(--vscode-textPreformat-background); + padding: 1px 3px; + border-radius: 4px; + } + + pre code { + padding: 0; + } + + blockquote { + background: var(--vscode-textBlockQuote-background); + border-color: var(--vscode-textBlockQuote-border); + } + + kbd { + background-color: var(--vscode-keybindingLabel-background); + color: var(--vscode-keybindingLabel-foreground); + border-style: solid; + border-width: 1px; + border-radius: 3px; + border-color: var(--vscode-keybindingLabel-border); + border-bottom-color: var(--vscode-keybindingLabel-bottomBorder); + box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow); + vertical-align: middle; + padding: 1px 3px; + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-corner { + background-color: var(--vscode-editor-background); + } + + ::-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); + } + + ::highlight(find-highlight) { + background-color: var(--vscode-editor-findMatchHighlightBackground); + } + + ::highlight(current-find-highlight) { + background-color: var(--vscode-editor-findMatchBackground); + } +}`; diff --git a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts index 93aee448b07ee..accf9c70ba9a6 100644 --- a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts +++ b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts @@ -5,7 +5,6 @@ import { VSBufferReadableStream } from '../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { isUNC } from '../../../../base/common/extpath.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { FileOperationError, FileOperationResult, IFileService, IWriteFileOptions } from '../../../../platform/files/common/files.js'; @@ -13,6 +12,7 @@ import { ServicesAccessor } from '../../../../platform/instantiation/common/inst import { ILogService } from '../../../../platform/log/common/log.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { getWebviewContentMimeType } from '../../../../platform/webview/common/mimeTypes.js'; +import { isWebviewResourceAllowed } from '../../../../platform/webview/common/resourceLoading.js'; export namespace WebviewResourceResponse { export enum Type { Success, Failed, AccessDenied, NotModified } @@ -105,40 +105,9 @@ export function getResourceToLoad( roots: ReadonlyArray, uriIdentityService: IUriIdentityService, ): URI | undefined { - const requestUriNoQueryString = requestUri.with({ query: '' }); - for (const root of roots) { - if (containsResource(root, requestUriNoQueryString, uriIdentityService)) { - return normalizeResourcePath(requestUri); - } - } - - return undefined; -} - -function containsResource(root: URI, resource: URI, uriIdentityService: IUriIdentityService): boolean { - if (uriIdentityService.extUri.isEqual(root, resource, /* ignoreFragment */ true)) { - return false; - } - - // Compare unc paths case-insensitively - if (root.scheme === Schemas.file && isUNC(root.fsPath)) { - if (resource.scheme === Schemas.file && isUNC(resource.fsPath)) { - return uriIdentityService.extUri.isEqualOrParent( - resource.with({ - path: resource.path.toLowerCase(), - authority: resource.authority.toLowerCase() - }), - root.with({ - path: root.path.toLowerCase(), - authority: root.authority.toLowerCase() - }), - /* ignoreFragment */ true - ); - } - return false; - } - - return uriIdentityService.extUri.isEqualOrParent(resource, root, /* ignoreFragment */ true); + return isWebviewResourceAllowed(requestUri, roots, uriIdentityService) + ? normalizeResourcePath(requestUri) + : undefined; } function normalizeResourcePath(resource: URI): URI { diff --git a/src/vs/workbench/contrib/webview/browser/webview.ts b/src/vs/workbench/contrib/webview/browser/webview.ts index f950b7b5a64e6..27c70871bc32a 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.ts @@ -183,6 +183,7 @@ function areEnableCommandUrisEqual(a: WebviewContentOptions, b: WebviewContentOp export interface WebviewExtensionDescription { readonly location?: URI; readonly id: ExtensionIdentifier; + readonly useSingleIframe?: boolean; } export interface WebviewMessageReceivedEvent { @@ -191,6 +192,8 @@ export interface WebviewMessageReceivedEvent { } export interface IWebview extends IDisposable { + /** Internal resource route identifier assigned by the extension host bridge. */ + resourceId?: string; /** * The original view type of the webview. diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index ba5d10ebe6bf5..c9a07e23bb3da 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -12,7 +12,7 @@ import { promiseWithResolvers, ThrottledDelayer } from '../../../../base/common/ import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { COI } from '../../../../base/common/network.js'; import { observableValue } from '../../../../base/common/observable.js'; import { listenStream } from '../../../../base/common/stream.js'; @@ -75,8 +75,12 @@ interface WebviewActionContext { const webviewIdContext = 'webviewId'; export class WebviewElement extends Disposable implements IWebviewElement, WebviewFindDelegate { + private readonly _directMessageHandler = this._register(new MutableDisposable()); protected readonly id = generateUuid(); + private _resourceId: string | undefined; + public get resourceId(): string | undefined { return this._resourceId; } + public set resourceId(value: string | undefined) { this._resourceId = value; this.onWebviewRouteChanged(); } /** * The provided identifier of this webview. @@ -89,6 +93,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi public readonly origin: string; private _windowId: number | undefined = undefined; + protected get windowId(): number | undefined { return this._windowId; } private get window() { return typeof this._windowId === 'number' ? getWindowById(this._windowId)?.window : undefined; } private _encodedWebviewOriginPromise?: Promise; @@ -135,6 +140,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi private _state: WebviewState.State = new WebviewState.Initializing([]); private _content: WebviewContent; + protected get content(): WebviewContent { return this._content; } private readonly _portMappingManager: WebviewPortMappingManager; @@ -161,7 +167,11 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi private _disposed = false; - public extension: WebviewExtensionDescription | undefined; + private _extension: WebviewExtensionDescription | undefined; + public get extension(): WebviewExtensionDescription | undefined { return this._extension; } + public set extension(value: WebviewExtensionDescription | undefined) { this._extension = value; this.onWebviewRouteChanged(); } + protected get useSingleIframe(): boolean { return this.platform === 'electron' && this.extension?.useSingleIframe === true; } + protected onWebviewRouteChanged(): void { } private readonly _options: WebviewOptions; constructor( @@ -412,13 +422,23 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } } - private _createElement(options: WebviewOptions, _contentOptions: WebviewContentOptions) { + private _createElement(options: WebviewOptions, contentOptions: WebviewContentOptions) { // Do not start loading the webview yet. // Wait the end of the ctor when all listeners have been hooked up. const element = document.createElement('iframe'); element.name = this.id; element.className = `webview ${options.customClasses || ''}`; - element.sandbox.add('allow-scripts', 'allow-same-origin', 'allow-forms', 'allow-pointer-lock', 'allow-downloads'); + if (this.useSingleIframe) { + element.sandbox.add('allow-scripts', 'allow-pointer-lock'); + if (contentOptions.allowForms ?? contentOptions.allowScripts) { + element.sandbox.add('allow-forms'); + } + if (contentOptions.allowScripts) { + element.sandbox.add('allow-downloads'); + } + } else { + element.sandbox.add('allow-scripts', 'allow-same-origin', 'allow-forms', 'allow-pointer-lock', 'allow-downloads'); + } const allowRules = ['cross-origin-isolated', 'autoplay', 'local-network-access']; if (!isFirefox) { @@ -437,7 +457,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi return element; } - private _initElement(encodedWebviewOrigin: string, extension: WebviewExtensionDescription | undefined, options: WebviewOptions, targetWindow: CodeWindow) { + protected _initElement(encodedWebviewOrigin: string, extension: WebviewExtensionDescription | undefined, options: WebviewOptions, targetWindow: CodeWindow) { // The extensionId and purpose in the URL are used for filtering in js-debug: const params: { [key: string]: string } = { id: this.id, @@ -507,18 +527,24 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi element.appendChild(this.element); } - private _registerMessageHandler(targetWindow: CodeWindow) { - const subscription = this._register(addDisposableListener(targetWindow, 'message', (e: MessageEvent) => { + protected _registerMessageHandler(targetWindow: CodeWindow) { + const subscription = addDisposableListener(targetWindow, 'message', (e: MessageEvent) => { if (!this._encodedWebviewOrigin || e?.data?.target !== this.id) { return; } - if (e.origin !== this._webviewContentOrigin(this._encodedWebviewOrigin)) { + const validOrigin = this.useSingleIframe + ? e.origin === 'null' && e.source === this.element?.contentWindow + : e.origin === this._webviewContentOrigin(this._encodedWebviewOrigin); + if (!validOrigin) { console.log(`Skipped renderer receiving message due to mismatched origins: ${e.origin} ${this._webviewContentOrigin}`); return; } if (e.data.channel === 'webview-ready') { + if (!this.isValidWebviewReady(e.data.data)) { + return; + } if (this._messagePort) { return; } @@ -545,7 +571,21 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi subscription.dispose(); } - })); + }); + if (this.useSingleIframe) { + this._directMessageHandler.value = subscription; + } else { + this._register(subscription); + } + } + + protected isValidWebviewReady(_data: unknown): boolean { return true; } + + protected prepareForDirectNavigation(targetWindow: CodeWindow): void { + this._messagePort = undefined; + const pending = this._state.type === WebviewState.Type.Initializing ? this._state.pendingMessages : []; + this._state = new WebviewState.Initializing(pending); + this._registerMessageHandler(targetWindow); } private perfMark(name: string) { @@ -608,7 +648,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } private _hasAlertedAboutMissingCsp = false; - private handleNoCspFound(): void { + protected handleNoCspFound(): void { if (this._hasAlertedAboutMissingCsp) { return; } @@ -660,6 +700,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi ...this._content, options: { ...this._content.options, localResourceRoots: resources } }; + this.onContentDidChange(); } public set state(state: string | undefined) { @@ -674,6 +715,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi this._logService.debug(`Webview(${this.id}): will update content`); this._content = newContent; + this.onContentDidChange(); const allowScripts = !!this._content.options.allowScripts; this.perfMark('set-content'); @@ -691,6 +733,8 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi }); } + protected onContentDidChange(): void { } + protected style(): void { let { styles, activeTheme, themeLabel, themeId } = this.webviewThemeDataProvider.getWebviewThemeData(); if (this._options.transformCssVariables) { @@ -936,10 +980,16 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi }); } + protected loadDirectResource(uri: URI, options: { ifNoneMatch: string | undefined; range?: { readonly start: number; readonly end?: number } }, token: CancellationToken): Promise { + return this._instantiationService.invokeFunction(loadLocalResource, uri, { + ifNoneMatch: options.ifNoneMatch, + roots: this._content.options.localResourceRoots || [], + range: options.range, + }, token); + } + private async localLocalhost(id: string, origin: string) { - const authority = this._environmentService.remoteAuthority; - const resolveAuthority = authority ? await this._remoteAuthorityResolverService.resolveAuthority(authority) : undefined; - const redirect = resolveAuthority ? await this._portMappingManager.getRedirect(resolveAuthority.authority, origin) : undefined; + const redirect = await this.getDirectLocalhostRedirect(origin); return this._send('did-load-localhost', { id, origin, @@ -947,6 +997,12 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi }); } + protected async getDirectLocalhostRedirect(origin: string): Promise { + const authority = this._environmentService.remoteAuthority; + const resolveAuthority = authority ? await this._remoteAuthorityResolverService.resolveAuthority(authority) : undefined; + return resolveAuthority ? this._portMappingManager.getRedirect(resolveAuthority.authority, origin) : undefined; + } + public focus(): void { this._doFocus(); diff --git a/src/vs/workbench/contrib/webview/common/webview.ts b/src/vs/workbench/contrib/webview/common/webview.ts index 95c65048fcd89..b3f163a74c060 100644 --- a/src/vs/workbench/contrib/webview/common/webview.ts +++ b/src/vs/workbench/contrib/webview/common/webview.ts @@ -24,6 +24,18 @@ export const webviewRootResourceAuthority = `vscode-resource.${webviewResourceBa export const webviewGenericCspSource = `'self' https://*.${webviewResourceBaseHost}`; +export interface ElectronWebviewResourceRoute { + readonly extensionId: string; + readonly webviewId: string; +} + +const webviewExtensionIdPattern = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/; + +export function normalizeWebviewExtensionId(extensionId: string): string | undefined { + const normalized = extensionId.toLowerCase(); + return webviewExtensionIdPattern.test(normalized) ? normalized : undefined; +} + /** * Construct a uri that can load resources inside a webview * @@ -37,7 +49,7 @@ export const webviewGenericCspSource = `'self' https://*.${webviewResourceBaseHo * @param resource Uri of the resource to load. * @param remoteInfo Optional information about the remote that specifies where `resource` should be resolved from. */ -export function asWebviewUri(resource: URI, remoteInfo?: WebviewRemoteInfo): URI { +export function asWebviewUri(resource: URI, remoteInfo?: WebviewRemoteInfo, electronRoute?: ElectronWebviewResourceRoute): URI { if (resource.scheme === Schemas.http || resource.scheme === Schemas.https) { return resource; } @@ -50,6 +62,20 @@ export function asWebviewUri(resource: URI, remoteInfo?: WebviewRemoteInfo): URI }); } + if (electronRoute) { + const extensionId = normalizeWebviewExtensionId(electronRoute.extensionId); + if (!extensionId) { + throw new Error(`Invalid extension id for webview route: ${electronRoute.extensionId}`); + } + return URI.from({ + scheme: Schemas.vscodeWebview, + authority: extensionId, + path: `/${electronRoute.webviewId}/_vscode/resource/${resource.scheme}+${encodeAuthority(resource.authority)}${resource.path}`, + fragment: resource.fragment, + query: resource.query, + }); + } + return URI.from({ scheme: Schemas.https, authority: `${resource.scheme}+${encodeAuthority(resource.authority)}.${webviewRootResourceAuthority}`, diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index ceea64ff9e0b6..ce8c22d8b7939 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -4,8 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import { Delayer } from '../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { listenStream } from '../../../../base/common/stream.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { CodeWindow } from '../../../../base/browser/window.js'; +import { createTrustedTypesPolicy } from '../../../../base/browser/trustedTypes.js'; import { Schemas } from '../../../../base/common/network.js'; +import { escape } from '../../../../base/common/strings.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { localize } from '../../../../nls.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -16,13 +24,122 @@ import { INativeHostService } from '../../../../platform/native/common/native.js import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; -import { FindInFrameOptions, IWebviewManagerService } from '../../../../platform/webview/common/webviewManagerService.js'; +import { FindInFrameOptions, IWebviewManagerService, WebviewResourceRequest } from '../../../../platform/webview/common/webviewManagerService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { WebviewThemeDataProvider } from '../browser/themeing.js'; import { WebviewInitInfo } from '../browser/webview.js'; import { WebviewElement } from '../browser/webviewElement.js'; +import { defaultWebviewStyles } from '../browser/pre/defaultStyles.js'; +import { WebviewResourceResponse } from '../browser/resourceLoading.js'; import { WindowIgnoreMenuShortcutsManager } from './windowIgnoreMenuShortcutsManager.js'; +const singleIframeBootstrap = String.raw`(() => { + const bootstrapElement = document.querySelector('meta[name="vscode-webview-bootstrap"]'); + const bootstrap = bootstrapElement?.content ? JSON.parse(decodeURIComponent(bootstrapElement.content)) : {}; + bootstrapElement?.remove(); + const channel = new MessageChannel(); + let acquired = false; + const stateElement = document.querySelector('meta[name="vscode-webview-state"]'); + let state = stateElement?.content ? JSON.parse(decodeURIComponent(stateElement.content)) : undefined; + stateElement?.remove(); + const post = (channelName, data, transfer = []) => channel.port1.postMessage({ channel: channelName, data }, transfer); + + globalThis.acquireVsCodeApi = () => { + if (acquired && !bootstrap.allowMultipleAPIAcquire) { throw new Error('An instance of the VS Code API has already been acquired'); } + acquired = true; + return Object.freeze({ + postMessage(message, transfer) { post('onmessage', { message, transfer }, transfer); }, + setState(newState) { state = newState; post('do-update-state', JSON.stringify(newState)); return newState; }, + getState() { return state; } + }); + }; + + let lastStyleData; + const applyStyles = data => { + lastStyleData = data; + for (const [key, value] of Object.entries(data.styles || {})) { + document.documentElement.style.setProperty('--' + key, String(value)); + } + if (!document.body) { return; } + document.body?.classList.remove('vscode-light', 'vscode-dark', 'vscode-high-contrast', 'vscode-high-contrast-light', 'vscode-reduce-motion', 'vscode-using-screen-reader'); + if (data.activeTheme) { document.body?.classList.add(data.activeTheme); } + if (data.reduceMotion) { document.body?.classList.add('vscode-reduce-motion'); } + if (data.screenReader) { document.body?.classList.add('vscode-using-screen-reader'); } + document.body?.setAttribute('data-vscode-theme-kind', data.activeTheme || ''); + document.body?.setAttribute('data-vscode-theme-name', data.themeLabel || ''); + document.body?.setAttribute('data-vscode-theme-id', data.themeId || ''); + }; + + channel.port1.onmessage = event => { + const { channel: name, args } = event.data; + switch (name) { + case 'content': state = args.state; document.title = args.title || ''; break; + case 'styles': applyStyles(args); break; + case 'message': window.dispatchEvent(new MessageEvent('message', { data: args.message, origin: window.location.origin, source: window, ports: event.ports })); break; + case 'focus': window.focus(); break; + case 'execCommand': document.execCommand(args); break; + case 'initial-scroll-position': window.scrollTo(0, document.body.scrollHeight * args); break; + case 'set-title': document.title = args; break; + } + }; + + window.addEventListener('focus', () => post('did-focus')); + window.addEventListener('blur', () => post('did-blur')); + window.addEventListener('DOMContentLoaded', () => { if (lastStyleData) { applyStyles(lastStyleData); } }); + window.addEventListener('scroll', () => post('did-scroll', { scrollYPercentage: document.body.scrollHeight ? scrollY / document.body.scrollHeight : 0 }), { passive: true }); + window.addEventListener('wheel', event => post('did-scroll-wheel', { deltaMode: event.deltaMode, deltaX: event.deltaX, deltaY: event.deltaY, deltaZ: event.deltaZ }), { passive: true }); + const keyData = event => ({ key: event.key, keyCode: event.keyCode, code: event.code, shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, repeat: event.repeat, isTrusted: event.isTrusted }); + window.addEventListener('keydown', event => post('did-keydown', keyData(event))); + window.addEventListener('keyup', event => post('did-keyup', keyData(event))); + const hasOnlyFiles = event => event.dataTransfer?.items.length && Array.from(event.dataTransfer.items).every(item => item.kind === 'file'); + window.addEventListener('dragenter', event => { if (!event.defaultPrevented && !event.shiftKey && hasOnlyFiles(event)) { post('drag-start'); } }); + window.addEventListener('dragover', event => { event.preventDefault(); if (hasOnlyFiles(event)) { post('drag', { shiftKey: event.shiftKey }); } }); + window.addEventListener('drop', event => event.preventDefault()); + window.addEventListener('contextmenu', event => { + if (event.defaultPrevented) { return; } + event.preventDefault(); + let context = {}; + let element = event.target instanceof Element ? event.target : null; + while (element) { + element = element.closest('[data-vscode-context]'); + if (!element) { break; } + try { context = { ...JSON.parse(element.getAttribute('data-vscode-context')), ...context }; } + catch (error) { console.error("Error parsing 'data-vscode-context' as json", element, error); } + element = element.parentElement; + } + post('did-context-menu', { clientX: event.clientX, clientY: event.clientY, context }); + }); + document.addEventListener('click', event => { + if (!event.isTrusted) { return; } + const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null; + if (!anchor) { return; } + const href = anchor.getAttribute('href'); + const base = document.querySelector('base'); + if (href === '#') { + window.scrollTo(0, 0); + } else if (anchor.hash && (href === anchor.hash || (base && anchor.href === base.href + anchor.hash))) { + const fragment = anchor.hash.slice(1); + const decodedFragment = decodeURIComponent(fragment); + const scrollTarget = document.getElementById(fragment) ?? document.getElementById(decodedFragment); + if (scrollTarget) { + scrollTarget.scrollIntoView(); + } else if (decodedFragment.toLowerCase() === 'top') { + window.scrollTo(0, 0); + } + } else { + post('did-click-link', { uri: anchor.href }); + } + event.preventDefault(); + }); + + parent.postMessage({ target: bootstrap.target, channel: 'webview-ready', data: { generation: bootstrap.generation } }, '*', [channel.port2]); +})();`; + +const singleIframeHtmlPolicy = createTrustedTypesPolicy('singleIframeWebview', { + createHTML: value => value, + createScript: value => value, +}); + /** * Webview backed by an iframe but that uses Electron APIs to power the webview. */ @@ -35,6 +152,14 @@ export class ElectronWebviewElement extends WebviewElement { private readonly _webviewMainService: IWebviewManagerService; private readonly _iframeDelayer = this._register(new Delayer(200)); + private _directTargetWindow: CodeWindow | undefined; + private _directGeneration = 0; + private _directHandshakeId: string | undefined; + private _directContentKey: string | undefined; + private _directUpdate: Promise = Promise.resolve(); + private _directDisposed = false; + private _directRegisteredDocument: { readonly extensionId: string; readonly webviewId: string; readonly windowId: number } | undefined; + private readonly _directResourceRequests = new Map(); protected override get platform() { return 'electron'; } @@ -45,7 +170,7 @@ export class ElectronWebviewElement extends WebviewElement { @ITunnelService tunnelService: ITunnelService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IRemoteAuthorityResolverService remoteAuthorityResolverService: IRemoteAuthorityResolverService, - @ILogService logService: ILogService, + @ILogService private readonly _directLogService: ILogService, @IConfigurationService configurationService: IConfigurationService, @IMainProcessService mainProcessService: IMainProcessService, @INotificationService notificationService: INotificationService, @@ -55,11 +180,27 @@ export class ElectronWebviewElement extends WebviewElement { ) { super(initInfo, webviewThemeDataProvider, configurationService, contextMenuService, notificationService, environmentService, - logService, remoteAuthorityResolverService, tunnelService, accessibilityService, instantiationService); + _directLogService, remoteAuthorityResolverService, tunnelService, accessibilityService, instantiationService); this._webviewKeyboardHandler = new WindowIgnoreMenuShortcutsManager(configurationService, mainProcessService, _nativeHostService); this._webviewMainService = ProxyChannel.toService(mainProcessService.getChannel('webview')); + this._register(this._webviewMainService.onDidRequestWebviewResource(request => this.handleDirectResourceRequest(request))); + this._register(this._webviewMainService.onDidCancelWebviewResource(requestId => { + const request = this._directResourceRequests.get(requestId); + if (request) { + this._directResourceRequests.delete(requestId); + request.dispose(true); + } + })); + this._register(this._webviewMainService.onDidRequestWebviewPortMapping(async request => { + if (this.useSingleIframe + && request.extensionId.toLowerCase() === this.extension?.id.value.toLowerCase() + && request.webviewId === this.resourceId) { + const redirect = await this.getDirectLocalhostRedirect(request.origin); + await this._webviewMainService.resolveWebviewPortMapping(request.requestId, redirect); + } + })); if (initInfo.options.enableFindWidget) { this._register(this.onDidHtmlChange((newContent) => { @@ -78,10 +219,291 @@ export class ElectronWebviewElement extends WebviewElement { override dispose(): void { // Make sure keyboard handler knows it closed (#71800) this._webviewKeyboardHandler.didBlur(); + this._directDisposed = true; + this._directGeneration++; + void this.unregisterDirectDocument(); + for (const request of this._directResourceRequests.values()) { + request.dispose(true); + } + this._directResourceRequests.clear(); super.dispose(); } + private async handleDirectResourceRequest(request: WebviewResourceRequest): Promise { + if (!this.useSingleIframe + || request.extensionId.toLowerCase() !== this.extension?.id.value.toLowerCase() + || request.webviewId !== this.resourceId) { + return; + } + const cts = new CancellationTokenSource(); + this._directResourceRequests.set(request.requestId, cts); + let streaming = false; + const finish = () => { + if (this._directResourceRequests.delete(request.requestId)) { + cts.dispose(); + } + }; + try { + const result = await this.loadDirectResource(URI.revive(request.uri), { + ifNoneMatch: request.ifNoneMatch, + range: request.range, + }, cts.token); + if (cts.token.isCancellationRequested) { + return; + } + switch (result.type) { + case WebviewResourceResponse.Type.Success: { + const requestedEnd = request.range?.end ?? result.size - 1; + const rangeEnd = Math.min(requestedEnd, result.size - 1); + await this._webviewMainService.startWebviewResourceResponse({ + requestId: request.requestId, + status: request.range ? 206 : 200, + mime: result.mimeType, + etag: result.etag, + mtime: result.mtime, + size: result.size, + range: request.range ? `bytes ${request.range.start}-${rangeEnd}/${result.size}` : undefined, + }); + if (request.method === 'HEAD') { + return; + } + streaming = true; + listenStream(result.stream, { + onData: data => void this._webviewMainService.streamWebviewResourceResponse(request.requestId, data), + onError: () => { void this._webviewMainService.endWebviewResourceResponse(request.requestId, true); finish(); }, + onEnd: () => { void this._webviewMainService.endWebviewResourceResponse(request.requestId); finish(); }, + }, cts.token); + return; + } + case WebviewResourceResponse.Type.NotModified: + await this._webviewMainService.startWebviewResourceResponse({ requestId: request.requestId, status: 304, mime: result.mimeType, etag: undefined, mtime: result.mtime, size: undefined, range: undefined }); + return; + case WebviewResourceResponse.Type.AccessDenied: + await this._webviewMainService.startWebviewResourceResponse({ requestId: request.requestId, status: 401, mime: undefined, etag: undefined, mtime: undefined, size: undefined, range: undefined }); + return; + default: + await this._webviewMainService.startWebviewResourceResponse({ requestId: request.requestId, status: 404, mime: undefined, etag: undefined, mtime: undefined, size: undefined, range: undefined }); + } + } catch { + await this._webviewMainService.startWebviewResourceResponse({ requestId: request.requestId, status: 404, mime: undefined, etag: undefined, mtime: undefined, size: undefined, range: undefined }); + } finally { + if (!streaming || cts.token.isCancellationRequested) { + finish(); + } + } + } + + public override reload(): void { + if (!this.useSingleIframe) { + super.reload(); + return; + } + this._directContentKey = undefined; + void this.updateDirectDocument(true); + } + + protected override _initElement(encodedWebviewOrigin: string, extension: WebviewInitInfo['extension'], options: WebviewInitInfo['options'], targetWindow: CodeWindow): void { + if (!this.useSingleIframe) { + super._initElement(encodedWebviewOrigin, extension, options, targetWindow); + return; + } + this._directTargetWindow = targetWindow; + void this.updateDirectDocument(false); + } + + protected override onWebviewRouteChanged(): void { + if (this.useSingleIframe && this._directTargetWindow) { + void this.updateDirectDocument(true); + } + } + + protected override onContentDidChange(): void { + if (this.useSingleIframe && this._directTargetWindow) { + void this.updateDirectDocument(true); + } + } + + protected override isValidWebviewReady(data: unknown): boolean { + return !this.useSingleIframe + || (typeof data === 'object' + && data !== null + && (data as { generation?: string }).generation === this._directHandshakeId); + } + + private async updateDirectDocument(prepareForNavigation: boolean): Promise { + const extensionId = this.extension?.id.value.toLowerCase(); + const webviewId = this.resourceId; + const targetWindow = this._directTargetWindow; + const windowId = this.windowId; + if (this._directDisposed || !extensionId || !webviewId || !targetWindow || typeof windowId !== 'number' || !this.element) { + return; + } + + const content = this.content; + const contentKey = JSON.stringify({ + extensionId, + webviewId, + windowId, + html: content.html, + allowMultipleAPIAcquire: content.options.allowMultipleAPIAcquire, + allowScripts: content.options.allowScripts, + allowForms: content.options.allowForms, + roots: content.options.localResourceRoots?.map(root => root.toString()), + }); + if (contentKey === this._directContentKey) { + return; + } + this._directContentKey = contentKey; + + const generation = ++this._directGeneration; + const handshakeId = generateUuid(); + this._directHandshakeId = handshakeId; + if (prepareForNavigation) { + this.prepareForDirectNavigation(targetWindow); + this.style(); + } + + const update = this._directUpdate.then(async () => { + const transformed = await this.transformDirectHtml(content.html, !!content.options.allowScripts, { + target: this.id, + generation: handshakeId, + allowMultipleAPIAcquire: !!content.options.allowMultipleAPIAcquire, + }, content.state, content.title); + if (this._directDisposed || generation !== this._directGeneration) { + return; + } + const registeredDocument = this._directRegisteredDocument; + if (registeredDocument + && (registeredDocument.extensionId !== extensionId + || registeredDocument.webviewId !== webviewId + || registeredDocument.windowId !== windowId)) { + await this.unregisterDirectDocument(); + if (this._directDisposed || generation !== this._directGeneration) { + return; + } + } + await this._webviewMainService.registerWebviewDocument({ + extensionId, + webviewId, + windowId, + frameName: this.id, + html: transformed.html, + csp: transformed.csp, + roots: content.options.localResourceRoots || [], + }); + if (this._directDisposed || generation !== this._directGeneration || !this.element) { + await this._webviewMainService.unregisterWebviewDocument(extensionId, webviewId); + this._directRegisteredDocument = undefined; + return; + } + this._directRegisteredDocument = { extensionId, webviewId, windowId }; + + this.element.sandbox.remove('allow-same-origin', 'allow-forms', 'allow-downloads'); + this.element.sandbox.add('allow-scripts', 'allow-pointer-lock'); + if (content.options.allowForms ?? content.options.allowScripts) { + this.element.sandbox.add('allow-forms'); + } + if (content.options.allowScripts) { + this.element.sandbox.add('allow-downloads'); + } + this.element.title = content.title || ''; + this.element.src = `${Schemas.vscodeWebview}://${extensionId}/${encodeURIComponent(webviewId)}/index.html`; + }); + this._directUpdate = update.then(undefined, error => { + if (generation === this._directGeneration) { + this._directContentKey = undefined; + } + this._directLogService.error(`Webview(${this.id}): failed to update direct webview`, error); + }); + await this._directUpdate; + } + + private async unregisterDirectDocument(): Promise { + const registeredDocument = this._directRegisteredDocument; + if (!registeredDocument) { + return; + } + this._directRegisteredDocument = undefined; + await this._webviewMainService.unregisterWebviewDocument(registeredDocument.extensionId, registeredDocument.webviewId); + } + + private async transformDirectHtml(html: string, allowScripts: boolean, bootstrapData: { readonly target: string; readonly generation: string; readonly allowMultipleAPIAcquire: boolean }, persistedState: string | undefined, title: string | undefined): Promise<{ html: string; csp: string }> { + const source = html || ''; + const trustedSource = singleIframeHtmlPolicy?.createHTML(source) ?? source; + const parsedDocument = new DOMParser().parseFromString(trustedSource as string, 'text/html'); + const policies = Array.from(parsedDocument.head.children) + .filter(element => element.tagName === 'META' && element.getAttribute('http-equiv')?.toLowerCase() === 'content-security-policy'); + if (policies.length !== 1 || !policies[0].getAttribute('content')?.trim()) { + this.handleNoCspFound(); + return { + html: `${escape(localize('webviewBlockedMissingCsp', "Webview blocked: the experimental loader requires exactly one Content-Security-Policy meta tag."))}`, + csp: `default-src 'none'; style-src 'unsafe-inline'`, + }; + } + let csp = policies[0].getAttribute('content')!.trim(); + policies[0].remove(); + const hash = await this.contentHash(singleIframeBootstrap); + const styleHash = await this.contentHash(defaultWebviewStyles); + csp = this.addHash(csp, 'script-src', hash); + if (this.hasDirective(csp, 'script-src-elem')) { + csp = this.addHash(csp, 'script-src-elem', hash); + } + csp = this.addHash(csp, 'style-src', styleHash); + if (this.hasDirective(csp, 'style-src-elem')) { + csp = this.addHash(csp, 'style-src-elem', styleHash); + } + if (!allowScripts) { + csp += `, script-src ${hash}; script-src-attr 'none'`; + } + const script = parsedDocument.createElement('script'); + script.text = (singleIframeHtmlPolicy?.createScript?.(singleIframeBootstrap) ?? singleIframeBootstrap) as string; + parsedDocument.head.prepend(script); + const bootstrap = parsedDocument.createElement('meta'); + bootstrap.name = 'vscode-webview-bootstrap'; + bootstrap.content = encodeURIComponent(JSON.stringify(bootstrapData)); + parsedDocument.head.prepend(bootstrap); + const defaultStyles = parsedDocument.createElement('style'); + defaultStyles.id = '_defaultStyles'; + defaultStyles.textContent = defaultWebviewStyles; + parsedDocument.head.prepend(defaultStyles); + const stateElement = parsedDocument.createElement('meta'); + stateElement.name = 'vscode-webview-state'; + stateElement.content = persistedState ? encodeURIComponent(persistedState) : ''; + parsedDocument.head.prepend(stateElement); + parsedDocument.title = title || ''; + return { html: `\n${parsedDocument.documentElement.outerHTML}`, csp }; + } + + private async contentHash(value: string): Promise { + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))); + let binary = ''; + for (const value of digest) { binary += String.fromCharCode(value); } + return `'sha256-${btoa(binary)}'`; + } + + private addHash(csp: string, directive: string, hash: string): string { + const directives = csp.split(';').map(value => value.trim()).filter(Boolean); + const index = directives.findIndex(value => value.split(/\s/, 1)[0].toLowerCase() === directive); + if (index >= 0) { + directives[index] += ` ${hash}`; + } else { + directives.push(`${directive} ${hash}`); + } + return directives.join('; '); + } + + private hasDirective(csp: string, directive: string): boolean { + return csp.split(';').some(value => value.trim().split(/\s/, 1)[0].toLowerCase() === directive); + } + + public override setTitle(title: string): void { + super.setTitle(title); + if (this.useSingleIframe && this.element) { + this.element.title = title; + } + } + protected override webviewContentEndpoint(iframeId: string): string { return `${Schemas.vscodeWebview}://${iframeId}`; } diff --git a/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts index 2ae1d202ac77e..cd33b366deaf3 100644 --- a/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts +++ b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { isWindows } from '../../../../../base/common/platform.js'; +import { Schemas } from '../../../../../base/common/network.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { FileService } from '../../../../../platform/files/common/fileService.js'; @@ -13,6 +14,7 @@ import { ILogService, NullLogService } from '../../../../../platform/log/common/ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; import { getResourceToLoad } from '../../browser/resourceLoading.js'; +import { asWebviewUri, normalizeWebviewExtensionId } from '../../common/webview.js'; suite('Webview Resource Loading - getResourceToLoad', () => { const disposableStore = ensureNoDisposablesAreLeakedInTestSuite(); @@ -234,6 +236,16 @@ suite('Webview Resource Loading - getResourceToLoad', () => { assert.strictEqual(result?.toString(), resource.toString()); }); + test('Returns resource equal to one root when it is contained by another root', () => { + const roots = [ + URI.file('/home/user/project/subdir'), + URI.file('/home/user/project') + ]; + const resource = URI.file('/home/user/project/subdir'); + const result = getResourceToLoad(resource, roots, uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + test('handles empty roots array', () => { const resource = URI.file('/home/user/project/file.txt'); const result = getResourceToLoad(resource, [], uriIdentityService); @@ -241,3 +253,31 @@ suite('Webview Resource Loading - getResourceToLoad', () => { }); }); }); + +suite('Webview Resource Loading - direct Electron routes', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('normalizes and validates extension identifiers', () => { + assert.strictEqual(normalizeWebviewExtensionId('Publisher.Extension-Name'), 'publisher.extension-name'); + assert.strictEqual(normalizeWebviewExtensionId('missingPublisher'), undefined); + assert.strictEqual(normalizeWebviewExtensionId('publisher.extension/other'), undefined); + }); + + test('constructs readable instance routes and preserves URI components', () => { + const result = asWebviewUri( + URI.from({ scheme: 'test-provider', authority: 'remote+host', path: '/目录/a file.css', query: 'v=1', fragment: 'rule' }), + undefined, + { extensionId: 'Publisher.Extension', webviewId: 'instance id' }, + ); + assert.strictEqual(result.scheme, Schemas.vscodeWebview); + assert.strictEqual(result.authority, 'publisher.extension'); + assert.strictEqual(result.path, '/instance id/_vscode/resource/test-provider+remote-002bhost/目录/a file.css'); + assert.strictEqual(result.query, 'v=1'); + assert.strictEqual(result.fragment, 'rule'); + }); + + test('leaves network resources unchanged', () => { + const resource = URI.parse('https://example.com/image.png?q=1#fragment'); + assert.strictEqual(asWebviewUri(resource, undefined, { extensionId: 'publisher.extension', webviewId: 'instance' }), resource); + }); +}); diff --git a/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts b/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts index acde3e67dfa53..68bbd4361da36 100644 --- a/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts +++ b/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts @@ -27,7 +27,7 @@ import { ExtensionKeyedWebviewOriginStore, IOverlayWebview, IWebviewService, Web import { WebviewWindowDragMonitor } from '../../webview/browser/webviewWindowDragMonitor.js'; import { IWebviewViewService, WebviewView } from './webviewViewService.js'; import { IActivityService, NumberBadge } from '../../../services/activity/common/activity.js'; -import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { IExtensionService, isProposedApiEnabled } from '../../../services/extensions/common/extensions.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; const storageKeys = { @@ -166,13 +166,20 @@ export class WebviewViewPane extends ViewPane { this._activated = true; const origin = this.extensionId ? WebviewViewPane.getOriginStore(this.storageService).getOrigin(this.id, this.extensionId) : undefined; + const extensionDescription = this.extensionId + ? this.extensionService.extensions.find(extension => ExtensionIdentifier.equals(extension.identifier, this.extensionId)) + : undefined; const webview = this.webviewService.createWebviewOverlay({ origin, providedViewType: this.id, title: this.title, options: { purpose: WebviewContentPurpose.WebviewView }, contentOptions: {}, - extension: this.extensionId ? { id: this.extensionId } : undefined + extension: extensionDescription ? { + id: extensionDescription.identifier, + location: extensionDescription.extensionLocation, + useSingleIframe: isProposedApiEnabled(extensionDescription, 'webviewNoServiceWorker') + } : this.extensionId ? { id: this.extensionId } : undefined }); webview.state = this.viewState[storageKeys.webviewState]; this._webview.value = webview; diff --git a/src/vscode-dts/vscode.proposed.webviewNoServiceWorker.d.ts b/src/vscode-dts/vscode.proposed.webviewNoServiceWorker.d.ts new file mode 100644 index 0000000000000..175226c1e30ea --- /dev/null +++ b/src/vscode-dts/vscode.proposed.webviewNoServiceWorker.d.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// This proposal is selected through package.json#enabledApiProposals. It does not add API members. +declare module 'vscode' { }